Create an infinite scrolling blog roll in Rails with Hotwire
In this tutorial, I’ll show you how to add an infinitely scrolling blog roll using Rails and Hotwire. Note that this is different than Chris Oliver’s awesome infinite scroll tutorial, in that we’re loading a new post once a user scrolls to the bottom of a current post. Below is a demo.
Step 1: Application Set-Up
rails new rails-infinite-scroll-posts -d-postgresql --webpacker=stimulus
rails db:setup
bundle add turbo-rails
rails turbo:install
Step 2: Create Post Scaffold
rails g scaffold post title body:text
rails db:migrate
Step 3: Add Seed Data
bundle add faker -g=development
-
Update
db/seeds.rb
10.times do |i| Post.create( title: "Post #{i + 1}", body: Faker::Lorem.paragraph(sentence_count: 500), ) end
rails db:seed
Step 4. Create the ability to navigate between Posts
-
touch app/models/concerns/navigable.rb
module Navigable extend ActiveSupport::Concern def next self.class.where("id > ?", self.id).order(id: :asc).first end def previous self.class.where("id < ?", self.id).order(id: :desc).first end end
-
Include Module in Post Model
class Post < ApplicationRecord include Navigable end
Note: We could just add the
next
andprevious
methods directly in thePost
model, but using a Module means we can use these methods in future models. -
Update PostsController
class PostsController < ApplicationController def show @next_post = @post.next end end
Step 5: Use Turbo Frames to lazy-load the next Post
-
Add frames to
app/views/posts/show.html.erb
<p id="notice"><%= notice %></p> <%# ℹ️ Add a turbo_frame_tag %> <%= turbo_frame_tag dom_id(@post) do %> <p> <strong>Title:</strong> <%= @post.title %> </p> <p> <strong>Body:</strong> <%= @post.body %> </p> <%# ℹ️ Add data_turbo_frame attribute %> <%= link_to 'Edit', edit_post_path(@post), data: { turbo_frame: "_top" } %> | <%= link_to 'Back', posts_path, data: { turbo_frame: "_top" } %> <%= turbo_frame_tag dom_id(@next_post), loading: :lazy, src: post_path(@next_post) if @next_post.present? %> <% end %>
What’s going on?
- We wrap the content in a
turbo_frame_tag
with anID
ofdom_id(@post)
. For example, thedom_id(@post)
call will evaluate toid="post_1"
if the Post’s ID is 1. This keeps the ID’s unique. - We add another
turbo_frame_tag
within the outerturbo_frame_tag
to lazy-load the next post. We can look for the next post thanks to ourNavigable
module that we created earlier.- The
loading
attribute ensures that the frame will only load once it appears in the viewport.
- The
- We add
data: { turbo_frame: "_top" }
to override navigation targets and force those pages to replace the whole frame. Otherwise, we would need to add Turbo Frames to theedit
andindex
views.- This is only because those links are nested in the outermost
turbo_frame_tag
.
- This is only because those links are nested in the outermost
Step 6: Use Stimulus to update the path as new posts are loaded
-
touch app/javascript/controllers/infinite_scroll_controller.js
import { Controller } from "stimulus"; export default class extends Controller { static targets = ["entry"]; static values = { path: String, }; connect() { this.createObserver(); } createObserver() { let observer; let options = { // https://github.com/w3c/IntersectionObserver/issues/124#issuecomment-476026505 threshold: [0, 1.0], }; observer = new IntersectionObserver( (entries) => this.handleIntersect(entries), options ); observer.observe(this.entryTarget); } handleIntersect(entries) { entries.forEach((entry) => { if (entry.isIntersecting) { // https://github.com/turbolinks/turbolinks/issues/219#issuecomment-376973429 history.replaceState(history.state, "", this.pathValue); } }); } }
-
Update that markup in
app/views/posts/show.html.erb
<p id="notice"><%= notice %></p> <%= turbo_frame_tag dom_id(@post) do %> <%# ℹ️ Wrap the content in a controller so it's scoped %> <div data-controller="infinite-scroll" data-infinite-scroll-path-value="<%= post_path(@post) %>" data-infinite-scroll-target="entry"> <p> <strong>Title:</strong> <%= @post.title %> </p> <p> <strong>Body:</strong> <%= @post.body %> </p> <%= link_to 'Edit', edit_post_path(@post), data: { turbo_frame: "_top" } %> | <%= link_to 'Back', posts_path, data: { turbo_frame: "_top" } %> </div> <%= turbo_frame_tag dom_id(@next_post), loading: :lazy, src: post_path(@next_post) if @next_post.present? %> <% end %>
What’s going on?
- We use the Intersection Observer API to determine when the post has entered the viewport.
- We set the
threshold
to[0, 1.0]
to account for elements that are taller than the viewport. This ensures thatentry.isIntersecting
will returntrue
.
- We set the
- When
entry.isIntersecting
returnstrue
, we use History.replaceState() to update the URL with the path for the post that entered the viewport.- The value for the path is stored in the
data-infinite-scroll-path-value
attribute. - We add
history.state
as the first argument tohistory.replaceState
to account for an issue with Turbolinks.
- The value for the path is stored in the
Step 7: Add a loading state and styles (optional)
-
Add Bootstrap via CDN to
app/views/layouts/application.html.erb
<!DOCTYPE html> <html> <head> <title>RailsInfiniteScrollPosts</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%# ℹ️ Load Bootstrap %> <%= stylesheet_link_tag 'https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css', integrity: 'sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl', crossorigin: 'anonymous' %> <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %> </head> <body> <%# ℹ️ Add a container %> <div class="container"> <%= yield %> </div> </body> </html>
-
Update markup and add a loader to
app/views/posts/show.html.erb
<p id="notice"><%= notice %></p> <%= turbo_frame_tag dom_id(@post) do %> <article data-controller="infinite-scroll" data-infinite-scroll-path-value="<%= post_path(@post) %>" data-infinite-scroll-target="entry"> <h2><%= @post.title %></h2> <p><%= @post.body %></p> <%= link_to 'Edit', edit_post_path(@post), data: { turbo_frame: "_top" } %> | <%= link_to 'Back', posts_path, data: { turbo_frame: "_top" } %> </article> <%= turbo_frame_tag dom_id(@next_post), loading: :lazy, src: post_path(@next_post) do %> <div class="d-flex justify-content-center"> <div class="spinner-border" role="status"> <span class="visually-hidden">Loading...</span> </div> </div> <% end if @next_post.present? %> <% end %>